Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 29a2c9f7c6308251d456d7f02b01bea65b946ae5


Parents : fcde1c9
Author : Mark Qvist <bc7291552be7a58f361522990465165c>
Date : 2026-05-06T18:44:17+02:00

Added better name sanitization options

Changes
Diff

diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py
index 336ae75..14844d3 100644
--- a/nomadnet/Conversation.py
+++ b/nomadnet/Conversation.py
@@ -5,6 +5,7 @@ import shutil
import RNS.vendor.umsgpack as msgpack
import nomadnet
from nomadnet.Directory import DirectoryEntry
+from LXMF import display_name_from_app_data
class Conversation:
cached_conversations = {}
@@ -16,6 +17,9 @@ class Conversation:
def received_announce(destination_hash, announced_identity, app_data):
app = nomadnet.NomadNetworkApp.get_shared_instance()
+ if not display_name_from_app_data(app_data):
+ RNS.log("Ignored invalid lxmf.delivery announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG)
+
if not destination_hash in app.ignored_list:
destination_hash_text = RNS.hexrep(destination_hash, delimit=False)
# Check if the announced destination is in
@@ -32,8 +36,7 @@ class Conversation:
# for nomadnets storage and other handling functions.
dn = LXMF.display_name_from_app_data(app_data)
app_data = b""
- if dn != None:
- app_data = dn.encode("utf-8")
+ if dn != None: app_data = dn.encode("utf-8")
# Add the announce to the directory announce
# stream logger
@@ -670,9 +673,16 @@ class ConversationMessage:
if os.path.isfile(manifest_path):
try:
with open(manifest_path, "rb") as f:
- return msgpack.unpackb(f.read(), raw=False)
- except Exception:
- pass
+ manifest = msgpack.unpackb(f.read(), raw=False)
+ for f in manifest["files"]:
+ f["name"] = os.path.basename(f["name"]).encode("utf-8").decode("utf-8")
+
+ return manifest
+
+ except Exception as e:
+ RNS.log(f"Error loading attachment manifest: {e}")
+ return None
+
return None
@staticmethod
@@ -702,13 +712,17 @@ class ConversationMessage:
file_attachments = fields.get(LXMF.FIELD_FILE_ATTACHMENTS, [])
if file_attachments:
for idx, att in enumerate(file_attachments):
- if isinstance(att, list) and len(att) >= 2:
- filename = str(att[0])
- data = att[1] if isinstance(att[1], bytes) else b""
- stored_name = "file_"+str(idx)
- with open(os.path.join(att_dir, stored_name), "wb") as f:
- f.write(data)
- manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)})
+ try:
+ if isinstance(att, list) and len(att) >= 2:
+ filename = os.path.basename(str(att[0])).encode("utf-8").decode("utf-8")
+ data = att[1] if isinstance(att[1], bytes) else b""
+ stored_name = "file_"+str(idx)
+ with open(os.path.join(att_dir, stored_name), "wb") as f: f.write(data)
+ manifest["files"].append({"name": filename, "stored_name": stored_name, "size": len(data)})
+
+ except Exception as e:
+ RNS.log(f"Error decoding file attachment: {e}", RNS.LOG_ERROR)
+ continue
if LXMF.FIELD_IMAGE in fields:
fmt, data = ConversationMessage._unpack_media_field(fields[LXMF.FIELD_IMAGE])

diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py
index 442983b..a3f86d5 100644
--- a/nomadnet/Directory.py
+++ b/nomadnet/Directory.py
@@ -8,6 +8,7 @@ import RNS.vendor.umsgpack as msgpack
from LXMF import pn_announce_data_is_valid
from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
class PNAnnounceHandler:
def __init__(self, owner):
@@ -156,11 +157,9 @@ class Directory:
try:
remove_announces = []
for announce in self._peer_announces:
- if announce[1] == source_hash:
- remove_announces.append(announce)
+ if announce[1] == source_hash: remove_announces.append(announce)
- for a in remove_announces:
- self._peer_announces.remove(a)
+ for a in remove_announces: self._peer_announces.remove(a)
except Exception as e:
RNS.log("An error occurred while compacting the announce stream. The contained exception was:"+str(e), RNS.LOG_ERROR)
@@ -272,26 +271,27 @@ class Directory:
else:
return None
- def simplest_display_str(self, source_hash):
+ def simplest_display_str(self, source_hash, san=True):
+ def s(name):
+ if self.app.config["textui"]["sanitize_names"] and san: return sanitize_name(name)
+ else: return strip_modifiers(name)
+
trust_level = self.trust_level(source_hash)
if trust_level == DirectoryEntry.WARNING or trust_level == DirectoryEntry.UNTRUSTED:
if source_hash in self.directory_entries:
- dn = self.directory_entries[source_hash].display_name
- if dn == None:
- return RNS.prettyhexrep(source_hash)
- else:
- return strip_modifiers(dn)+" <"+RNS.hexrep(source_hash, delimit=False)+">"
- else:
- return "<"+RNS.hexrep(source_hash, delimit=False)+">"
+ dn = s(self.directory_entries[source_hash].display_name)
+ if not dn: return RNS.prettyhexrep(source_hash)
+ else: return dn+" <"+RNS.hexrep(source_hash, delimit=False)+">"
+
+ else: return "<"+RNS.hexrep(source_hash, delimit=False)+">"
+
else:
if source_hash in self.directory_entries:
- dn = self.directory_entries[source_hash].display_name
- if dn == None:
- return RNS.prettyhexrep(source_hash)
- else:
- return strip_modifiers(dn)
- else:
- return "<"+RNS.hexrep(source_hash, delimit=False)+">"
+ dn = s(self.directory_entries[source_hash].display_name)
+ if not dn: return RNS.prettyhexrep(source_hash)
+ else: return dn
+
+ else: return "<"+RNS.hexrep(source_hash, delimit=False)+">"
def alleged_display_str(self, source_hash):
if source_hash in self.directory_entries:

diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py
index 13f3371..62d2086 100644
--- a/nomadnet/NomadNetworkApp.py
+++ b/nomadnet/NomadNetworkApp.py
@@ -838,6 +838,11 @@ class NomadNetworkApp:
else:
self.config["textui"]["hide_guide"] = self.config["textui"].as_bool("hide_guide")
+ if not "sanitize_names" in self.config["textui"]:
+ self.config["textui"]["sanitize_names"] = True
+ else:
+ self.config["textui"]["sanitize_names"] = self.config["textui"].as_bool("sanitize_names")
+
if not "animation_interval" in self.config["textui"]:
self.config["textui"]["animation_interval"] = 1
else:
@@ -1178,6 +1183,11 @@ editor = nano
# show up in the menu, you can disable it.
hide_guide = no
+# You can select whether or not to perform
+# text sanitization on names received in
+# announces.
+sanitize_names = yes
+
[node]
# Whether to enable node hosting

diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py
index a1356f3..48bd74f 100644
--- a/nomadnet/ui/TextUI.py
+++ b/nomadnet/ui/TextUI.py
@@ -28,6 +28,7 @@ THEMES = {
("error_text", "dark red", "default", "default", "dark red", "default"),
("warning_text", "yellow", "default", "default", "#ba4", "default"),
("inactive_text", "dark gray", "default", "default", "dark gray", "default"),
+ ("browser_inactive", "dark gray", "default", "default", "#444", "default"),
("buttons", "light green,bold", "default", "default", "#00a533", "default"),
("msg_editor", "black", "light cyan", "standout", "#111", "#0bb"),
("msg_header_ok", "black", "light green", "standout", "#111", "#6b2"),

diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py
index f4785bc..4c0fc57 100644
--- a/nomadnet/ui/textui/Browser.py
+++ b/nomadnet/ui/textui/Browser.py
@@ -13,6 +13,7 @@ from .MicronParser import markup_to_attrmaps, make_style, default_state
from nomadnet.Directory import DirectoryEntry
from nomadnet.vendor.Scrollable import *
from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
class BrowserFrame(urwid.Frame):
def keypress(self, size, key):
@@ -288,6 +289,10 @@ class Browser:
def handle_lxmf_link(self, link_target):
try:
+ def san(name):
+ if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name)
+ else: return strip_modifiers(name)
+
if not type(link_target) is str:
raise ValueError("Invalid data type for LXMF link")
@@ -307,7 +312,7 @@ class Browser:
display_name = None
if display_name_data != None:
- display_name = LXMF.display_name_from_app_data(display_name_data)
+ display_name = san(LXMF.display_name_from_app_data(display_name_data))
if not source_hash_text in [c[0] for c in existing_conversations]:
entry = DirectoryEntry(bytes.fromhex(source_hash_text), display_name=display_name)
@@ -343,7 +348,7 @@ class Browser:
self.frame = BrowserFrame(self.browser_body, header=self.browser_header, footer=self.browser_footer)
self.frame.delegate = self
self.linebox = urwid.LineBox(self.frame, title="Remote Node")
- self.display_widget = urwid.AttrMap(self.linebox, "inactive_text")
+ self.display_widget = urwid.AttrMap(self.linebox, "browser_inactive")
def make_status_widget(self):
if self.response_progress > 0:
@@ -389,7 +394,7 @@ class Browser:
def update_display(self):
if self.status == Browser.DISCONECTED:
- self.display_widget.set_attr_map({None: "inactive_text"})
+ self.display_widget.set_attr_map({None: "browser_inactive"})
self.page_pile = None
self.page_partials = {}
self.browser_body = urwid.Filler(
@@ -1073,7 +1078,7 @@ class Browser:
self.page_foreground_color = fg
try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
- except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")]
+ except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")]
self.response_progress = 0
self.response_speed = None
@@ -1147,7 +1152,7 @@ class Browser:
self.page_foreground_color = fg
try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
- except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")]
+ except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")]
self.response_progress = 0
self.response_speed = None
@@ -1306,7 +1311,7 @@ class Browser:
self.page_foreground_color = fg
try: self.attr_maps = markup_to_attrmaps(strip_modifiers(self.markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
- except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "inactive_text")]
+ except Exception as e: self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render page: {e}"), "browser_inactive")]
self.response_progress = 0
self.response_speed = None

diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py
index e68f3c2..2af34f1 100644
--- a/nomadnet/ui/textui/Conversations.py
+++ b/nomadnet/ui/textui/Conversations.py
@@ -11,6 +11,9 @@ from datetime import datetime, timedelta
from nomadnet.Directory import DirectoryEntry
from nomadnet.Conversation import ConversationMessage
+from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
+
def relative_time(timestamp):
now = time.time()
@@ -1083,6 +1086,10 @@ class ConversationWidget(urwid.WidgetWrap):
super().__init__(self.display_widget)
def _update_peer_info(self):
+ def san(name):
+ if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name)
+ else: return strip_modifiers(name)
+
g = self.app.ui.glyphs
source_hash_bytes = bytes.fromhex(self.source_hash)
@@ -1093,7 +1100,7 @@ class ConversationWidget(urwid.WidgetWrap):
if display_name is None:
if app_data:
- display_name = LXMF.display_name_from_app_data(app_data)
+ display_name = san(LXMF.display_name_from_app_data(app_data))
if display_name is None:
display_name = RNS.prettyhexrep(source_hash_bytes)

diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py
index 03becdd..ce6bcc5 100644
--- a/nomadnet/ui/textui/Guide.py
+++ b/nomadnet/ui/textui/Guide.py
@@ -803,6 +803,12 @@ The `!nerdfont`! set allows using a much wider range of glyphs, icons and graphi
Determines whether the program should react to mouse/touch input. Must be a boolean value.
<
+>>>
+`!sanitize_names = yes`!
+>>>>
+This option allows sanitizing announced names before they are displayed in the user interface.
+<
+
>>>
`!hide_guide = no`!
>>>>

diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py
index fcf2a4e..aead47d 100644
--- a/nomadnet/ui/textui/Network.py
+++ b/nomadnet/ui/textui/Network.py
@@ -7,6 +7,7 @@ from datetime import datetime
from nomadnet.Directory import DirectoryEntry
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY
from nomadnet.util import strip_modifiers
+from nomadnet.util import sanitize_name
from .Browser import Browser
@@ -275,16 +276,19 @@ class AnnounceStreamEntry(urwid.WidgetWrap):
ts_string = dt.strftime(date_only_format)
trust_level = self.app.directory.trust_level(source_hash)
+
+ def san(name):
+ if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name)
+ else: return strip_modifiers(name)
if show_destination:
display_str = RNS.hexrep(source_hash, delimit=False)
else:
- try:
- display_str = strip_modifiers(announce[2].decode("utf-8"))
- if len(display_str) > 32:
- display_str = display_str[:32] + "..."
- except:
- display_str = self.app.directory.simplest_display_str(source_hash)
+ try: display_str = san(announce[2].decode("utf-8"))
+ except: display_str = self.app.directory.simplest_display_str(source_hash)
+
+ if not display_str: display_str = RNS.prettyhexrep(source_hash)
+ if len(display_str) > 34: display_str = display_str[:34] + "…"
if trust_level == DirectoryEntry.UNTRUSTED:
symbol = g["cross"]
@@ -488,16 +492,15 @@ class AnnounceStream(urwid.WidgetWrap):
if announce_type == "node" or announce_type == True:
node_count += 1
- if self.current_tab == "nodes":
- new_entries.append(e)
+ if self.current_tab == "nodes": new_entries.append(e)
+
elif announce_type == "peer" or announce_type == False:
peer_count += 1
- if self.current_tab == "peers":
- new_entries.append(e)
+ if self.current_tab == "peers": new_entries.append(e)
+
elif announce_type == "pn":
pn_count += 1
- if self.current_tab == "pn":
- new_entries.append(e)
+ if self.current_tab == "pn": new_entries.append(e)
for e in new_entries:
nw = AnnounceStreamEntry(self.app, e, self, show_destination=self.show_destination)
@@ -980,7 +983,7 @@ class NodeEntry(urwid.WidgetWrap):
g = self.app.ui.glyphs
trust_level = self.app.directory.trust_level(source_hash)
- display_str = self.app.directory.simplest_display_str(source_hash)
+ display_str = self.app.directory.simplest_display_str(source_hash, san=False)
if trust_level == DirectoryEntry.UNTRUSTED:
symbol = g["cross"]

diff --git a/nomadnet/util.py b/nomadnet/util.py
index 0d8c79f..9f2562a 100644
--- a/nomadnet/util.py
+++ b/nomadnet/util.py
@@ -3,6 +3,59 @@ import unicodedata
invalid_rendering = ["🕵️", "☝"]
+# Unicode blocks to strip (symbols, dingbats, emoji ranges)
+STRIP_BLOCKS_RE = re.compile(
+ '['
+ '\U0001F600-\U0001F64F' # Emoticons
+ '\U0001F300-\U0001F5FF' # Misc Symbols & Pictographs
+ '\U0001F680-\U0001F6FF' # Transport & Map Symbols
+ '\U0001F700-\U0001F77F' # Alchemical Symbols
+ '\U0001F780-\U0001F7FF' # Geometric Shapes Extended
+ '\U0001F800-\U0001F8FF' # Supplemental Arrows-C
+ '\U0001F900-\U0001F9FF' # Supplemental Symbols & Pictographs
+ '\U0001FA00-\U0001FA6F' # Chess Symbols
+ '\U0001FA70-\U0001FAFF' # Symbols & Pictographs Extended-A
+ '\U0001F1E0-\U0001F1FF' # Flags (iOS/regional indicators)
+ '\u2600-\u26FF' # Misc Symbols (☀, ☁, ☂, etc.)
+ '\u2700-\u27BF' # Dingbats (✂, ✈, ✉, ✌, etc.)
+ '\uFE00-\uFE0F' # Variation Selectors
+ '\U000E0100-\U000E01EF' # Variation Selectors Supplement
+ '\U0001F3FB-\U0001F3FF' # Emoji modifiers (skin tones)
+ ']+',
+ flags=re.UNICODE
+)
+
+# Control characters and zero-width characters to strip
+STRIP_CONTROL_RE = re.compile(
+ '['
+ '\x00-\x08' # C0 controls (NUL-BS)
+ '\x0B\x0C' # VT, FF
+ '\x0E-\x1F' # C0 controls (SO-US)
+ '\x7F-\x9F' # DEL and C1 controls
+ '\u200B-\u200F' # Zero-width chars, LRM, RLM, etc.
+ '\u202A-\u202E' # Bidi embedding controls
+ '\u2060-\u206F' # Format chars (word joiner, etc.)
+ '\uFEFF' # BOM / Zero Width NBSP
+ '\uFFF0-\uFFF8' # Specials
+ ']+',
+ flags=re.UNICODE
+)
+
+# Surrogates and private use areas
+# Shouldn't appear in valid UTF-8, but strip just in case
+STRIP_PRIVATE_RE = re.compile(
+ '['
+ '\uD800-\uDFFF' # Surrogates
+ '\uE000-\uF8FF' # Private Use Area
+ '\uF900-\uFAFF' # CJK Compatibility Ideographs (keep? strip for safety)
+ '\uFE10-\uFE1F' # Vertical Forms
+ '\uFE20-\uFE2F' # Combining Half Marks
+ '\U000F0000-\U000FFFFF' # Supplementary Private Use Area-A
+ '\U00100000-\U0010FFFF' # Supplementary Private Use Area-B
+ ']+',
+ flags=re.UNICODE
+)
+
def strip_modifiers(text):
def process_characters(text):
result = []
@@ -14,14 +67,18 @@ def strip_modifiers(text):
if category.startswith(('L', 'N', 'P', 'S')):
result.append(char)
i += 1
+
elif category.startswith(('M', 'Sk', 'Cf')) or char in '\u200d\u200c':
i += 1
+
else:
result.append(char)
i += 1
return ''.join(result)
+ if text == None: return None
+
for char in invalid_rendering:
text = text.replace(char, " ")
@@ -32,4 +89,55 @@ def strip_modifiers(text):
stripped = re.sub(r'[\u200D\u200C]', '', stripped)
stripped = re.sub(r'\r\n?', '\n', stripped)
- return stripped
+ return stripped.strip().replace("\x00", "")
+
+def sanitize_name(name):
+ if name is None: return None
+
+ # Convert to string and normalize to NFKC
+ # NFKC: Compatibility decomposition followed by canonical composition
+ # This handles: ① to 1, Ⅰ to I, etc., while keeping composed forms
+ name = str(name)
+ name = unicodedata.normalize('NFKC', name)
+
+ # Build result using category-based filtering
+ result = []
+ for char in name:
+ cat = unicodedata.category(char)
+ cat_prefix = cat[0] if cat else 'C'
+
+ # Allow letters (L*), numbers (N*), and punctuation (P*)
+ if cat_prefix in ('L', 'N', 'P'): result.append(char)
+
+ # Allow space separator, normalize to regular space
+ elif cat == 'Zs': result.append(' ')
+
+ # Convert line/paragraph separators to space
+ elif cat in ('Zl', 'Zp'): result.append(' ')
+
+ # Allow spacing combining marks (Mc) for Indic, Hebrew, etc.
+ elif cat == 'Mc': result.append(char)
+
+ # Allow modifier letters (Lm) - e.g., ʰ, ʱ, ː
+ elif cat == 'Lm': result.append(char)
+
+ # Strip everything else:
+ # - Mn (Nonspacing Mark): diacritics, combining marks (Zalgo)
+ # - Me (Enclosing Mark): enclosing combining marks
+ # - C* (Controls, Format, Surrogates, Private Use, Unassigned)
+ # - S* (Symbols: currency, math, modifiers, other)
+
+ name = ''.join(result)
+
+ # Additional block-based stripping for symbols that categories missed
+ name = STRIP_BLOCKS_RE.sub('', name)
+ name = STRIP_CONTROL_RE.sub('', name)
+ name = STRIP_PRIVATE_RE.sub('', name)
+
+ # Collapse multiple whitespace characters
+ name = re.sub(r'\s+', ' ', name)
+
+ # Strip leading/trailing whitespace
+ name = name.strip()
+
+ return name

diff --git a/setup.py b/setup.py
index 97b527d..593fe8c 100644
--- a/setup.py
+++ b/setup.py
@@ -31,6 +31,6 @@ setuptools.setup(
entry_points= {
'console_scripts': ['nomadnet=nomadnet.nomadnet:main']
},
- install_requires=["rns>=1.2.3", "lxmf>=0.9.6", "urwid>=2.6.16", "qrcode"],
- python_requires=">=3.7",
+ install_requires=["rns>=1.2.3", "lxmf>=0.9.7", "urwid>=2.6.16", "qrcode"],
+ python_requires=">=3.8",
)


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────